Docker 与 containerd 的关系
平时敲的每一条 docker 命令,背后都不是 Docker 一个进程在干活,而是一整条组件接力链。containerd 就是这条链上最关键的一环:它原本只是 Docker 的内部组件,后来独立成 CNCF 毕业项目,如今还是 Kubernetes 节点上的主流容器运行时。这篇附录讲清楚三件事:这条调用链长什么样、它是怎么一步步演进成这样的,以及如何绕开 Docker 单独使用 containerd。
调用链
以 docker run 为例,一条命令从敲下到容器跑起来,请求会沿着下面这条链一路传递:
docker CLI → dockerd → containerd → containerd-shim → runc → 容器进程
各组件的职责:
| 组件 | 是什么 | 职责 |
|---|---|---|
| docker CLI | 命令行客户端 | 用户入口,把命令通过 REST API(默认走 /var/run/docker.sock)发给 dockerd,自己不做实际工作 |
| dockerd | Docker 守护进程 | 对外提供 REST API,管理镜像、网络、数据卷、构建等上层功能,通过 gRPC 调用 containerd |
| containerd | 容器运行时管理器 | 负责镜像的拉取与存储、容器生命周期管理,是上层工具和底层运行时之间的桥梁 |
| containerd-shim | 垫片进程,每个容器一个 | 充当容器进程的「父进程」,让容器脱离 dockerd 和 containerd 独立存活,Daemon 重启或升级时容器不受影响 |
| runc | OCI 运行时参考实现 | 按 OCI 规范真正创建容器:配置 Namespace、Cgroups,启动容器内进程,做完就退出,把进程交给 shim 看管 |
| 容器进程 | 普通 Linux 进程 | 最终跑起来的应用本身,本质就是宿主机上一个被隔离的进程 |
执行 docker run -d --name demo nginx 的完整过程:
- docker CLI 把请求通过
/var/run/docker.sock发给 dockerd。 - dockerd 通过 gRPC 通知 containerd 创建容器。
- containerd 检查本地有没有
nginx镜像,没有就先从镜像仓库拉取。 - containerd 为这个容器拉起一个 containerd-shim 进程。
- shim 调用 runc,runc 按 OCI 规范配好 Namespace 和 Cgroups,启动容器内的 nginx 进程。
- runc 完成任务退出,shim 留下来当容器进程的父进程,负责传递标准输入输出、回收退出状态。
为什么要多一个 shim?如果容器进程直接挂在 containerd 下面,containerd 一旦升级或崩溃重启,所有容器都会跟着被杀。有了 shim 这层垫片,容器就有了自己的「监护人」,升级 Docker、重启 Daemon 都不会影响正在运行的容器。
这条链不是理论,在 Linux 宿主机上可以直接看到:
# 先启动一个常驻容器
docker run -d --name demo nginx
# 查看 dockerd、containerd、shim 进程
ps aux | grep -E 'dockerd|containerd|shim' | grep -v grep
输出大致如下:
root 1009 ... /usr/bin/containerd
root 1010 ... /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
root 2345 ... /usr/bin/containerd-shim-runc-v2 -namespace moby -id 3f4a5b... ...
注意 dockerd 启动参数里的 --containerd=/run/containerd/containerd.sock,这就是 dockerd 和 containerd 之间的 gRPC 通道。再验证链路的最后一段——容器进程的父进程确实是 shim:
# 找到容器主进程(nginx)在宿主机上的 PID
docker inspect -f '{{.State.Pid}}' demo
# 查看它的父进程 PID(PPID 一列,对应上面的 shim 进程)
ps -o pid,ppid,cmd -p $(docker inspect -f '{{.State.Pid}}' demo)
Mac 和 Windows 上的 Docker Desktop 把这些进程跑在一个内置 Linux 虚拟机里,宿主机终端直接 ps 是看不到的,需要在真正的 Linux 服务器上验证。
架构演进
Docker 不是一开始就这么分层的,这套架构是十多年间一步步拆出来的:
| 时间 | 事件 |
|---|---|
| 2013 | Docker 开源。早期是单体架构:dockerd 一个进程包揽所有事,底层最初基于 LXC,后来换成自研的 libcontainer |
| 2015 | Docker 联合多家公司成立 OCI(Open Container Initiative),制定镜像和运行时两大标准;同时把 libcontainer 独立出来捐给 OCI,成为参考实现 runc |
| 2016 | Docker 1.11 把容器管理功能从 dockerd 中拆出为独立组件 containerd,架构从此变成 dockerd → containerd → runc |
| 2017 | Docker 把 containerd 捐赠给 CNCF(云原生计算基金会),containerd 成为社区中立的容器运行时项目 |
| 2019 | containerd 从 CNCF 毕业,标志着它足够成熟、可用于生产环境 |
| 2020 | Kubernetes 1.20 宣布弃用 dockershim(K8s 中专门适配 Docker 的垫片层),推荐直接使用 containerd 等 CRI 运行时 |
| 2022 | Kubernetes 1.24 正式移除 dockershim,containerd 成为 K8s 节点上的主流容器运行时 |
早期 dockerd 什么都管:API 服务、镜像、网络、存储、构建、容器运行……任何一处升级或崩溃都会波及所有容器,其他工具想复用容器能力也只能整套依赖 Docker。拆分之后每层职责单一:dockerd 管上层功能,containerd 专心管容器生命周期,runc 只管按 OCI 标准创建容器。下层组件稳定且标准化,Kubernetes 这类平台可以直接对接 containerd,绕过整个 Docker。
「K8s 弃用 Docker」弃掉的只是 dockershim 这个适配层,不是 Docker 本身。Docker 构建的镜像完全符合 OCI 标准,在使用 containerd 运行时的 K8s 集群里照常运行;日常开发继续用 Docker 也没有任何问题。
单独使用 containerd
containerd 既然能脱离 dockerd 独立工作,就完全可以单独使用——Kubernetes 节点上通常就是这样:只装了 containerd,没有 Docker。这时有两个命令行工具可用:
- nerdctl:containerd 社区维护的 Docker 风格 CLI(名字即「containerd 的 ctl」),命令和参数与
docker几乎一模一样,还支持配合 BuildKit 构建镜像、用nerdctl compose跑 Compose 文件,适合日常使用。 - ctr:containerd 自带的底层调试工具,功能简陋、输出不友好,仅供排查 containerd 本身的问题,不适合日常操作。
常用命令对照
同一个操作,三个工具各自的写法:
| 操作 | docker | nerdctl | ctr |
|---|---|---|---|
| 查看镜像 | docker images | nerdctl images | ctr images ls |
| 拉取镜像 | docker pull nginx | nerdctl pull nginx | ctr images pull docker.io/library/nginx:latest |
| 运行容器 | docker run -d --name web nginx | nerdctl run -d --name web nginx | ctr run -d docker.io/library/nginx:latest web |
| 查看容器 | docker ps | nerdctl ps | ctr containers ls |
| 查看运行中的任务 | docker ps | nerdctl ps | ctr tasks ls |
| 进入容器 | docker exec -it web sh | nerdctl exec -it web sh | ctr tasks exec --exec-id sh1 -t web sh |
| 查看日志 | docker logs web | nerdctl logs web | (不支持) |
| 停止容器 | docker stop web | nerdctl stop web | ctr tasks kill web |
| 删除容器 | docker rm web | nerdctl rm web | ctr containers rm web |
| 构建镜像 | docker build -t app . | nerdctl build -t app . | (不支持,需单独使用 BuildKit) |
从这张表能看出 ctr 的几处「不友好」:
- 镜像名必须写完整地址(
docker.io/library/nginx:latest),不像 docker 会自动把nginx补全成 Docker Hub 官方镜像。 - 容器(
containers)和任务(tasks)是两个概念:containers 是静态的容器定义,tasks 才是真正跑起来的进程,看运行状态要用ctr tasks ls。 - 没有
logs,没有build,连ctr run之前都得先手动ctr images pull。
ctr 的设计目标本来就不是给人日常用的,它是 containerd 的调试入口。想脱离 Docker 日常操作 containerd,装一个 nerdctl 就够了,使用习惯和 docker 完全一致。
namespace 隔离
containerd 内部用 namespace 把不同使用方的数据隔开,互不干扰:
| namespace | 使用方 |
|---|---|
moby | Docker(dockerd 创建的容器都在这里) |
k8s.io | Kubernetes(kubelet 创建的 Pod 和容器) |
default | ctr 和 nerdctl 默认操作的 namespace |
所以在 K8s 节点上直接用 ctr,默认是什么都看不到的,要加 -n 指定 namespace:
# 查看 containerd 里有哪些 namespace
ctr namespaces ls
# 查看 K8s 创建的容器
ctr -n k8s.io containers ls
# nerdctl 同理
nerdctl -n k8s.io ps
K8s 节点上排查为什么用 crictl
在 K8s 节点上排查容器问题,更顺手的工具其实是 crictl,原因有三:
- 它和 kubelet 看的是同一份数据。crictl 走的是 CRI(容器运行时接口),和 kubelet 调用 containerd 用的是同一套接口,所以它看到的就是 kubelet 眼中的容器状态。
- 它认识 Pod。ctr 和 nerdctl 站在 containerd 层面,只知道容器、不知道 Pod;crictl 能列出 Pod 沙箱(sandbox),「这个容器属于哪个 Pod」一目了然。
- 运行时无关。不管节点上装的是 containerd 还是 CRI-O,crictl 的命令都一样。
常用命令:
crictl ps # 查看运行中的容器,类似 docker ps
crictl pods # 查看 Pod 沙箱
crictl images # 查看镜像
crictl logs <容器ID> # 查看容器日志
crictl inspect <容器ID> # 查看容器详情
crictl 需要知道 CRI 端点才能工作,一般写在 /etc/crictl.yaml 里:
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
不配的话每次都要加 --runtime-endpoint 参数。配好之后用法和 docker 基本一致,排查「容器起不来」「镜像拉不下来」这类问题非常顺手。
在 K8s 节点上不要用 ctr 或 nerdctl 直接修改、删除 K8s 管理的容器——这些操作 kubelet 完全不知情,会导致集群状态和实际状态对不上。用 ctr 只做只读排查(ls、ps),要动手操作就通过 kubectl 或 crictl。